﻿// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

#nullable disable

using System.Runtime.Versioning;
using Microsoft.DotNet.Cli.Commands.Workload;

#if !DOT_NET_BUILD_FROM_SOURCE
using Microsoft.DotNet.Cli.Installer.Windows.Security;
#endif

using Newtonsoft.Json;

namespace Microsoft.DotNet.Cli.Installer.Windows;

/// <summary>
/// Manages caching workload pack MSI packages.
/// </summary>
[SupportedOSPlatform("windows")]
internal class MsiPackageCache(InstallElevationContextBase elevationContext, ISetupLogger logger,
    bool verifySignatures, string packageCacheRoot = null) : InstallerBase(elevationContext, logger, verifySignatures)
{
    /// <summary>
    /// Determines whether revocation checks can go online.
    /// </summary>
    private readonly bool _allowOnlineRevocationChecks = SignCheck.AllowOnlineRevocationChecks();

    /// <summary>
    /// The root directory of the package cache where MSI workload packs are stored.
    /// </summary>
    public readonly string PackageCacheRoot = string.IsNullOrWhiteSpace(packageCacheRoot)
            ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "dotnet", "workloads")
            : packageCacheRoot;

    /// <summary>
    /// Moves the MSI payload described by the manifest file to the cache.
    /// </summary>
    /// <param name="packageId">The ID of the workload pack package containing an MSI.</param>
    /// <param name="packageVersion">The package version.</param>
    /// <param name="manifestPath">The JSON manifest associated with the workload pack MSI.</param>
    public void CachePayload(string packageId, string packageVersion, string manifestPath)
    {
        if (!File.Exists(manifestPath))
        {
            throw new FileNotFoundException($"CachePayload: Manifest file not found: {manifestPath}");
        }

        Elevate();

        if (IsElevated)
        {
            string packageDirectory = GetPackageDirectory(packageId, packageVersion);

            // Delete the package directory and create a new one that's secure. If all the files were properly
            // cached, the client would not request this action.
            if (Directory.Exists(packageDirectory))
            {
                Directory.Delete(packageDirectory, recursive: true);
            }

            SecurityUtils.CreateSecureDirectory(packageDirectory);

            // We cannot assume that the MSI adjacent to the manifest is the one to cache. We'll trust
            // the manifest to provide the MSI filename.
            MsiManifest msiManifest = JsonConvert.DeserializeObject<MsiManifest>(File.ReadAllText(manifestPath));
            // Only use the filename+extension of the payload property in case the manifest has been altered.
            string msiPath = Path.Combine(Path.GetDirectoryName(manifestPath), Path.GetFileName(msiManifest.Payload));

            string cachedMsiPath = Path.Combine(packageDirectory, Path.GetFileName(msiPath));
            string cachedManifestPath = Path.Combine(packageDirectory, Path.GetFileName(manifestPath));

            SecurityUtils.MoveAndSecureFile(manifestPath, cachedManifestPath, Log);
            SecurityUtils.MoveAndSecureFile(msiPath, cachedMsiPath, Log);
        }
        else if (IsClient)
        {
            Dispatcher.SendCacheRequest(InstallRequestType.CachePayload, manifestPath, packageId, packageVersion);
        }
    }

    /// <summary>
    /// Gets the full path of the cache directory for the specified package ID and version.
    /// </summary>
    /// <param name="packageId">The ID of the MSI workload pack package.</param>
    /// <param name="packageVersion">The version of the MSI workload pack package.</param>
    /// <returns>The directory where the MSI package will be cached.</returns>
    public string GetPackageDirectory(string packageId, string packageVersion)
    {
        return Path.Combine(PackageCacheRoot, packageId, packageVersion);
    }

    /// <summary>
    /// Determines if the workload pack MSI is cached and tries to retrieve its payload from the cache.
    /// </summary>
    /// <param name="packageId">The package ID of NuGet package carrying the MSI payload.</param>
    /// <param name="packageVersion">The version of the package.</param>
    /// <param name="payload">Contains the payload if the method returns <see langword="true"/>; otherwise the default value of <see cref="MsiPayload"/>.</param>
    /// <returns><see langwork="true"/> if the MSI is cached; <see langword="false"/> otherwise.</returns>
    public bool TryGetPayloadFromCache(string packageId, string packageVersion, out MsiPayload payload)
    {
        string packageCacheDirectory = GetPackageDirectory(packageId, packageVersion);
        payload = default;

        if (!TryGetMsiPathFromPackageData(packageCacheDirectory, out string msiPath, out string manifestPath))
        {
            return false;
        }

        VerifyPackageSignature(msiPath);

        payload = new MsiPayload(manifestPath, msiPath);

        return true;
    }

    public bool TryGetMsiPathFromPackageData(string packageDataPath, out string msiPath, out string manifestPath)
    {
        msiPath = default;
        manifestPath = Path.Combine(packageDataPath, "msi.json");

        // It's possible that the MSI is cached, but without the JSON manifest we cannot
        // trust that the MSI in the cache directory is the correct file.
        if (!File.Exists(manifestPath))
        {
            Log?.LogMessage($"MSI manifest file does not exist, '{manifestPath}'");
            return false;
        }

        // The msi.json manifest contains the name of the actual MSI. The filename does not necessarily match the package
        // ID as it may have been shortened to support VS caching.
        MsiManifest msiManifest = JsonConvert.DeserializeObject<MsiManifest>(File.ReadAllText(manifestPath));
        string possibleMsiPath = Path.Combine(Path.GetDirectoryName(manifestPath), msiManifest.Payload);

        if (!File.Exists(possibleMsiPath))
        {
            Log?.LogMessage($"MSI package not found, '{possibleMsiPath}'");
            return false;
        }

        msiPath = possibleMsiPath;
        return true;
    }

    /// <summary>
    /// Verifies that an MSI package contains an Authenticode signature that terminates in a trusted Microsoft root certificate.
    /// </summary>
    /// <param name="msiPath">The path of the MSI to verify.</param>
    private void VerifyPackageSignature(string msiPath)
    {
        if (VerifySignatures)
        {
            // MSI and authenticode verification only applies to Windows. NET only supports Win7 and later.
#if !DOT_NET_BUILD_FROM_SOURCE
#pragma warning disable CA1416
            unsafe
            {
                int result = Signature.IsAuthenticodeSigned(msiPath, _allowOnlineRevocationChecks);

                if (result != 0)
                {
                    ExitOnError((uint)result, $"Failed to verify Authenticode signature, package: {msiPath}, allow online revocation checks: {_allowOnlineRevocationChecks}");
                }

                result = Signature.HasMicrosoftTrustedRoot(msiPath);

                if (result != 0)
                {
                    ExitOnError((uint)result, $"Failed to verify the Authenticode signature terminates in a trusted Microsoft root certificate. Package: {msiPath}");
                }

            }
            Log?.LogMessage($"Successfully verified Authenticode signature for {msiPath}");
#pragma warning restore CA1416
#endif
        }
        else
        {
            Log?.LogMessage($"Skipping signature verification for {msiPath}.");
        }
    }
}
